a) 現象(question)
b) 難題(problem)
c) 阻礙
d) 期待
e) 目標
理解基礎概念:
識別漏洞:
預防技術:
實踐練習:
高級主題:
持續學習:
為什麼被視為重要的安全問題?
SQL injection 是一種針對資料庫驅動的應用程式的攻擊技術。攻擊者透過在輸入欄位中插入惡意的 SQL 程式碼,來操縱或破壞應用程式的資料庫查詢。
SQL injection 被視為重要的安全問題,因為它可能導致未授權訪問、資料洩露、資料篡改,甚至是整個系統的被接管。這種攻擊方式利用了應用程式對使用者輸入的信任,可能造成嚴重的安全漏洞。
SQL injection 的工作原理是透過在使用者輸入中插入特製的 SQL 程式碼片段,使得原本的 SQL 查詢的語義被改變。在文章中的不安全模式下,使用者輸入直接被拼接到 SQL 查詢字串中,例如:
const result = await db.query(`SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`);
這種方式使得攻擊者可以輸入如 ' or '1'='1' -- -
這樣的內容,改變查詢的邏輯,繞過身份驗證。
指出哪些部分容易受到 SQL injection 攻擊。
不安全程式碼主要體現在直接將使用者輸入拼接到 SQL 查詢字串中:
const result = await db.query(`SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`);
這行程式碼中,username
和 password
變量直接插入 SQL 字串,沒有進行任何轉義或驗證。
直接拼接使用者輸入到 SQL 查詢中是危險的,因為:
文章中的安全模式使用了參數化查詢:
const result = await db.query('SELECT * FROM users WHERE username = $1', [username]);
參數化查詢將 SQL 語句結構和資料分開處理。$1
是參數佔位符,實際值作為獨立參數傳遞。
這種方法能有效預防 SQL injection,因為:
文章中的安全模式已經使用了參數化查詢,這是一個很好的開始。進一步改進可以包括:
為了展示 SQL injection 且保留過於詳細的錯誤訊息的問題,因此設計一個功能提供給學習者可以切換不同的弱點。
web/public/login.html
<!-- 前面省略 -->
<!-- 安全/不安全切換 -->
<label for="safemode">安全模式</label>
<div>
<input type="radio" id="safemode-true" name="safemode" value="true" checked>
<label for="safemode-true">安全</label>
</div>
<div>
<input type="radio" id="safemode-false" name="safemode" value="false">
<label for="safemode-false">不安全</label>
</div>
// 前省略
// 取得安全模式的值
const safemode = document.querySelector('input[name="safemode"]:checked').value;
// 前省略
body: JSON.stringify({ username, password, safemode }),
web/controllers/authController.js
safemode
從前端會多送一個資訊給後端來查看async login(req, res) {
const { username, password, safemode } = req.body;
try {
// ...
} catch (error) {
res.status(500).json({ error: error.message });
}
},
if (safemode === 'true') {
const result = await db.query('SELECT * FROM users WHERE username = $1', [username]);
// 驗證帳號和密碼
// ...
}
// 如果沒有找到使用者,回傳 401 狀態碼
if (result.rows.length === 0) {
return res.status(401).json({ message: '使用者不存在' }); // 狀態過於詳細,不建議這樣寫,因為會讓駭客知道使用者不存在
// return res.status(401).json({ message: '使用者不存在或密碼錯誤' }); // 這樣寫比較好
}
// 沒有使用 bcrypt 進行雜湊,所以不用 await,直接比對密碼,但這樣不安全
const isPasswordValid = password === user.password;
// 要引入 bcrypt 進行雜湊,再比對密碼
// const isPasswordValid = await bcrypt.compare(password, user.password); // 有使用 bcrypt,所以要引入 bcrypt
// 不安全的寫法,容易被 SQL Injection 攻擊
const result = await db.query(`SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`);
if (result.rows.length === 0) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
try {
const user = result.rows[0];
username
和 password
變量插入到 SQL 查詢字串中。username
輸入 ' OR '1'='1
,那麼整個 WHERE 子句將始終為真,可能導致未授權訪問。if (result.rows.length === 0)
來檢查查詢結果。username
和 password
進行任何形式的輸入驗證或清理。result.rows[0]
可能包含帳號的所有資訊,包括可能的敏感資料。' or '1'='1' -- -
使用 ' or '1'='1' -- -
作為帳號。
原理解釋:
'
閉合原始 SQL 查詢中的字符串。or '1'='1'
增加一個永遠為真的條件。-- -
註解掉查詢的剩餘部分。結果:
這使得 WHERE 子句永遠為真,導致查詢回傳第一個使用者的資料,從而繞過認證。
原始查詢可能類似:
SELECT * FROM users WHERE username = '' or '1'='1' -- -' AND password = 'anything'
嘗試 ' UNION SELECT NULL,NULL,NULL,NULL,NULL FROM users -- -
嘗試 ' UNION SELECT NULL,NULL,NULL,NULL,NULL FROM users -- -
原理解釋:
'
閉合原始查詢的字符串。UNION
將另一個 SELECT 語句的結果與原始查詢結果合併。SELECT NULL,NULL,NULL,NULL,NULL
建立一個與原始查詢結果列數相同的結果集。FROM users
從使用者表中選擇資料。-- -
註解掉查詢的剩餘部分。結果:
這會回傳所有使用者的資料,即使原始查詢沒有配對的結果。
注意:需要猜測或知道原始查詢回傳的列數,這就是為什麼使用多個 NULL。
使用 '; SELECT CASE WHEN (username='testuser') THEN pg_sleep(10) ELSE pg_sleep(0) END FROM users -- -
使用 '; SELECT CASE WHEN (username='testuser') THEN pg_sleep(10) ELSE pg_sleep(0) END FROM users -- -
原理解釋:
';
結束原始查詢並開始一個新的查詢。SELECT CASE...END
建立一個條件語句。WHEN (username='testuser')
檢查是否存在使用者名為 'testuser' 的使用者。THEN pg_sleep(10) ELSE pg_sleep(0)
如果條件為真,則暫停 10 秒,否則立即回傳。FROM users
應用於使用者表。-- -
註解掉查詢的剩餘部分。結果:
如果 'testuser' 存在,查詢會延遲 10 秒。攻擊者可以透過觀察回應時間來推斷使用者是否存在。
驗證其對 SQL injection 的防禦效果